--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 8c8e2ace3d9ca0997278525b4b8e3dcf7a69b563
Parents : 0b4931a
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T09:26:09-05:00
feat(CI): add Android emulator smoke test workflow and associated scripts for health verification of the Chaquopy backend
Changes
4 files changed, 543 insertions(+), 19 deletions(-)
Diff
diff --git a/.github/workflows/android-emulator-smoke.yml b/.github/workflows/android-emulator-smoke.yml
new file mode 100644
index 00000000..f24c5f48
--- /dev/null
+++ b/.github/workflows/android-emulator-smoke.yml
@@ -0,0 +1,201 @@
+# Nightly / manual Android emulator smoke: build x86_64 debug APK, install on
+# an AVD, launch MainActivity, and require /api/v1/status == ok.
+# Catches Chaquopy boot failures (missing modules, storage lock SystemExit, etc.)
+# that assembleDebug alone cannot see.
+#
+# Pinned first-party actions (bump tag and SHA together when upgrading):
+# actions/checkout@v6.0.1 8e8c483db84b4bee98b60c0593521ed34d9990e8
+# actions/setup-python@v6.2.0 a309ff8b426b58ec0e2a45f0f869d46889d02405
+# actions/setup-java@v4.7.1 c5195efecf7bdfc987ee8bae7a71cb8b11521c00
+# actions/upload-artifact@v5.0.0 330a01c490aca151604b8cf639adc76d48f6c5d4
+# actions/download-artifact@v5.0.0 634f93cb2916e3fdff6788551b99b062d0335ce0
+# reactivecircus/android-emulator-runner@v2.38.0
+# a421e43855164a8197daf9d8d40fe71c6996bb0d
+
+name: Android emulator smoke
+
+on:
+ schedule:
+ # After nightly release tagging window; independent of release success.
+ - cron: "30 8 * * *"
+ workflow_dispatch:
+ inputs:
+ api_level:
+ description: Android API level for the emulator AVD
+ required: true
+ type: string
+ default: "30"
+
+permissions:
+ contents: read
+ actions: write
+
+concurrency:
+ group: android-emulator-smoke-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ NODE_OPTIONS: --max-old-space-size=8192
+ NODE_VERSION: "24"
+ PNPM_VERSION: "11.1.2"
+ PYTHON_VERSION: "3.11"
+ JAVA_VERSION: "17"
+ CHAQUOPY_REF: "9f563f45108a873d7feb363e1f754c0173f1114e"
+ MESHCHATX_ABIS: "x86_64"
+
+jobs:
+ frontend:
+ name: Build frontend artifact
+ uses: ./.github/workflows/frontend-build.yml
+ permissions:
+ contents: read
+ with:
+ artifact_name: meshchatx-frontend-android-smoke-${{ github.run_id }}-${{ github.run_attempt }}
+ retention_days: 1
+ python_version: "3.11"
+
+ smoke:
+ name: Emulator smoke (API ${{ github.event.inputs.api_level || '30' }})
+ runs-on: ubuntu-latest
+ needs: frontend
+ timeout-minutes: 120
+ permissions:
+ contents: read
+ actions: write
+ defaults:
+ run:
+ shell: bash
+ env:
+ FRONTEND_ARTIFACT_NAME: ${{ needs.frontend.outputs.artifact_name }}
+ API_LEVEL: ${{ github.event.inputs.api_level || '30' }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
+
+ - name: Enable KVM
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+ ls -l /dev/kvm
+
+ - name: Set up Java
+ uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00
+ with:
+ distribution: temurin
+ java-version: ${{ env.JAVA_VERSION }}
+ cache: gradle
+ cache-dependency-path: |
+ android/build.gradle
+ android/settings.gradle
+ android/app/build.gradle
+
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Download frontend artifact
+ uses: ./.github/actions/fetch-frontend-artifact
+ with:
+ artifact-name: ${{ env.FRONTEND_ARTIFACT_NAME }}
+
+ - name: Install Android wheel build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y build-essential cmake pkg-config patchelf
+
+ - name: Install Rust toolchain
+ run: |
+ curl -sSfL https://sh.rustup.rs -o rustup-init.sh
+ chmod +x rustup-init.sh
+ ./rustup-init.sh -y --profile minimal --default-toolchain stable
+ echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+
+ - name: Install Android NDK for Chaquopy native wheels
+ run: |
+ set -euo pipefail
+ SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-/usr/local/lib/android/sdk}}"
+ SDKMANAGER="${SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager"
+ if [[ ! -x "${SDKMANAGER}" ]]; then
+ echo "Expected preinstalled sdkmanager at ${SDKMANAGER}" >&2
+ exit 1
+ fi
+ NDK_VERSION="27.3.13750724"
+ yes_n() { for _ in $(seq 1 "$1"); do echo y; done; }
+ if [[ ! -d "${SDK_ROOT}/ndk/${NDK_VERSION}" ]]; then
+ yes_n 200 | "${SDKMANAGER}" --sdk_root="${SDK_ROOT}" --licenses >/dev/null
+ yes_n 50 | "${SDKMANAGER}" --sdk_root="${SDK_ROOT}" "ndk;${NDK_VERSION}" >/dev/null
+ fi
+ {
+ echo "ANDROID_HOME=${SDK_ROOT}"
+ echo "ANDROID_SDK_ROOT=${SDK_ROOT}"
+ } >> "${GITHUB_ENV}"
+
+ - name: Build Android wheels (x86_64 only)
+ run: bash scripts/build-android-wheels-local.sh --python-minor "${PYTHON_VERSION}" --chaquopy-ref "${CHAQUOPY_REF}" --abis x86_64
+
+ - name: Build MeshChatX wheel for repository bundle
+ run: |
+ set -euo pipefail
+ python -m pip install -U pip build
+ python -m build --wheel -o dist .
+
+ - name: Verify required x86_64 Android wheels
+ run: |
+ set -euo pipefail
+ required=(
+ "cbor2-5.6.5-*-cp311-cp311-android_24_x86_64.whl"
+ "aiohttp-3.14.1-*-cp311-cp311-android_24_x86_64.whl"
+ "cryptography-49.0.0-*-cp311-cp311-android_24_x86_64.whl"
+ "miniaudio-1.70-*-cp311-cp311-android_24_x86_64.whl"
+ "pycodec2-*-cp311-cp311-android_24_x86_64.whl"
+ "chaquopy_libcodec2-*-android_24_x86_64.whl"
+ "lxst-*-py3-none-any.whl"
+ "bleak-*-py3-none-any.whl"
+ )
+ missing=0
+ for pattern in "${required[@]}"; do
+ if ! ls android/vendor/${pattern} >/dev/null 2>&1; then
+ echo "::error::Missing wheel matching android/vendor/${pattern}"
+ missing=1
+ fi
+ done
+ if [[ "${missing}" -ne 0 ]]; then
+ ls -la android/vendor/ || true
+ exit 1
+ fi
+
+ - name: Build x86_64 debug APK
+ working-directory: android
+ env:
+ MESHCHATX_ABIS: x86_64
+ run: |
+ chmod +x gradlew
+ ./gradlew --no-daemon -PmeshchatxAbis=x86_64 :app:assembleDebug
+
+ - name: Upload smoke APK
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
+ with:
+ name: meshchatx-android-smoke-apk-${{ github.run_id }}
+ path: android/app/build/outputs/apk/debug/*.apk
+ if-no-files-found: error
+ retention-days: 7
+
+ - name: Run emulator smoke
+ uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
+ with:
+ api-level: ${{ env.API_LEVEL }}
+ target: google_apis
+ arch: x86_64
+ profile: pixel_5
+ force-avd-creation: false
+ emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ disable-animations: true
+ script: |
+ set -euo pipefail
+ APK="$(ls -1 android/app/build/outputs/apk/debug/*.apk | head -n 1)"
+ echo "Using APK: ${APK}"
+ chmod +x scripts/ci/android-emulator-smoke.sh
+ MESHCHATX_SMOKE_TIMEOUT_SEC=180 bash scripts/ci/android-emulator-smoke.sh "${APK}"
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index fa07349d..f8f1a523 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -383,6 +383,7 @@ class ReticulumMeshChat:
migration_context: dict | None = None,
memory_diag_enabled: bool = False,
plugins_enabled: bool = True,
+ defer_network_setup: bool = False,
):
self.running = True
self.plugins_enabled = plugins_enabled
@@ -427,6 +428,13 @@ class ReticulumMeshChat:
self.listen_port: int | None = None
self.use_https: bool = True
self.landlock_active: bool = False
+ self._pending_identity = identity
+ self._network_setup_lock = threading.Lock()
+ self._network_ready_event = threading.Event()
+ self._network_setup_thread: threading.Thread | None = None
+ self._startup_stage = "ready" if not defer_network_setup else "http"
+ self._startup_error: str | None = None
+ self._network_ready = not defer_network_setup
# track announce timestamps for rate calculation
self.announce_timestamps = []
@@ -453,8 +461,12 @@ class ReticulumMeshChat:
self._propagation_sync_metrics: dict[str, dict] = {}
AsyncUtils.ensure_background_loop()
- self.setup_identity(identity)
self.web_audio_bridge = WebAudioBridge(None, None)
+ if defer_network_setup:
+ self._set_startup_stage("http")
+ else:
+ self.setup_identity(identity)
+ self._mark_network_ready()
# Proxy properties for backward compatibility
@property
@@ -1231,6 +1243,108 @@ class ReticulumMeshChat:
guard_rnode_interfaces_on_desktop(config_path)
guard_invalid_rnode_txpower_in_config(config_path)
+ def _set_startup_stage(self, stage: str, error: str | None = None) -> None:
+ self._startup_stage = stage
+ if error is not None:
+ self._startup_error = error
+ print(f"Startup stage: {stage}", flush=True)
+
+ def _mark_network_ready(self) -> None:
+ self._network_ready = True
+ self._startup_stage = "ready"
+ self._startup_error = None
+ self._network_ready_event.set()
+
+ def _startup_status_payload(self) -> dict:
+ ready = bool(self._network_ready) and bool(
+ self.current_context and self.current_context.running,
+ )
+ stage = "ready" if ready else (self._startup_stage or "starting")
+ payload = {
+ "status": "ok" if ready else "starting",
+ "stage": stage,
+ "network_ready": ready,
+ "listen_host": self.listen_host,
+ "listen_port": self.listen_port,
+ "https_enabled": self.use_https,
+ "is_loopback_bind": _is_loopback_bind_host(self.listen_host),
+ "plugins_enabled": self.plugins_enabled,
+ **self._landlock_status_dict(),
+ }
+ if self._startup_error:
+ payload["error"] = self._startup_error
+ return payload
+
+ def wait_until_network_ready(self, timeout: float | None = None) -> bool:
+ if self._network_ready and self.current_context and self.current_context.running:
+ return True
+ return self._network_ready_event.wait(timeout)
+
+ def start_network_setup_in_background(self, identity: RNS.Identity | None = None) -> None:
+ pending = identity if identity is not None else self._pending_identity
+ if pending is None:
+ raise RuntimeError("No identity available for network setup")
+ self._pending_identity = pending
+ if self._network_ready and self.current_context and self.current_context.running:
+ return
+ with self._network_setup_lock:
+ if self._network_setup_thread and self._network_setup_thread.is_alive():
+ return
+ self._set_startup_stage("starting")
+ thread = threading.Thread(
+ target=self._run_network_setup,
+ name="meshchatx-network-setup",
+ daemon=True,
+ )
+ self._network_setup_thread = thread
+ thread.start()
+
+ def _run_network_setup(self) -> None:
+ identity = self._pending_identity
+ if identity is None:
+ self._set_startup_stage("failed", "No identity available for network setup")
+ return
+ try:
+ self._set_startup_stage("rns")
+ self.setup_identity(identity)
+ if self.config is not None and getattr(self, "session_secret_key", None):
+ try:
+ self.config.auth_session_secret.set(self.session_secret_key)
+ except Exception as exc:
+ print(f"Failed to persist session secret into config: {exc}")
+ self._mark_network_ready()
+ print("Network stack ready", flush=True)
+ try:
+ AsyncUtils.run_async(
+ self.websocket_broadcast(
+ {
+ "type": "startup_status",
+ "status": "ok",
+ "stage": "ready",
+ "network_ready": True,
+ },
+ ),
+ )
+ except Exception:
+ pass
+ except Exception as exc:
+ traceback.print_exc()
+ self._set_startup_stage("failed", str(exc))
+ try:
+ AsyncUtils.run_async(
+ self.websocket_broadcast(
+ {
+ "type": "startup_status",
+ "status": "failed",
+ "stage": "failed",
+ "network_ready": False,
+ "error": str(exc),
+ },
+ ),
+ )
+ except Exception:
+ pass
+
def setup_identity(self, identity: RNS.Identity):
identity_hash = identity.hash.hex()
@@ -1249,6 +1363,7 @@ class ReticulumMeshChat:
# Initialize Reticulum if not already done
if not hasattr(self, "reticulum"):
+ self._set_startup_stage("rns")
self._ensure_reticulum_config()
rns_loglevel = _resolve_rns_loglevel(self._rns_loglevel_cli)
if rns_loglevel is not None:
@@ -1259,6 +1374,7 @@ class ReticulumMeshChat:
else:
self.reticulum = RNS.Reticulum(self.reticulum_config_dir)
_restore_rns_console_logging_after_reticulum_init(self)
+ self._set_startup_stage("identity")
self.page_node_manager.load_nodes()
self.page_node_manager.start_all()
self.plugin_manager.set_app(self)
@@ -1266,6 +1382,7 @@ class ReticulumMeshChat:
self.plugin_manager.install_bundled_examples()
# Create new context
+ self._set_startup_stage("identity")
context = IdentityContext(identity, self)
self.contexts[identity_hash] = context
self.current_context = context
@@ -4211,6 +4328,11 @@ class ReticulumMeshChat:
if path == "/api/v1/status":
return await handler(request)
+ # Allow CSRF bootstrap and auth status while the network stack starts so the
+ # Vue shell can load and show an in-app waiting state.
+ if path in ("/api/v1/auth/csrf", "/api/v1/auth/status"):
+ return await handler(request)
+
# Serve the web UI shell and static files while an identity context is still
# starting, so the browser can load assets and show in-app loading state.
if not path.startswith("/api/"):
@@ -4243,7 +4365,12 @@ class ReticulumMeshChat:
if not self.current_context or not self.current_context.running:
return web.json_response(
- {"error": "Application is initializing or switching identity"},
+ {
+ "error": "Application is initializing or switching identity",
+ "status": "starting",
+ "stage": self._startup_stage,
+ "network_ready": False,
+ },
status=503,
)
@@ -4800,17 +4927,7 @@ class ReticulumMeshChat:
@routes.get("/api/v1/status")
async def status(request):
- return web.json_response(
- {
- "status": "ok",
- "listen_host": self.listen_host,
- "listen_port": self.listen_port,
- "https_enabled": self.use_https,
- "is_loopback_bind": _is_loopback_bind_host(self.listen_host),
- "plugins_enabled": self.plugins_enabled,
- **self._landlock_status_dict(),
- },
- )
+ return web.json_response(self._startup_status_payload())
@routes.get("/api/v1/self-test")
async def self_test(request):
@@ -4876,6 +4993,17 @@ class ReticulumMeshChat:
# auth status
@routes.get("/api/v1/auth/status")
async def auth_status(request):
+ if not self.current_context or not self.current_context.running:
+ return web.json_response(
+ {
+ "auth_enabled": self.auth_enabled,
+ "password_set": False,
+ "authenticated": False,
+ "network_ready": False,
+ "status": "starting",
+ "stage": self._startup_stage,
+ },
+ )
try:
session = await get_session(request)
is_authenticated = session.get("authenticated", False)
@@ -4892,6 +5020,7 @@ class ReticulumMeshChat:
"password_set": self.config.auth_password_hash.get()
is not None,
"authenticated": actually_authenticated,
+ "network_ready": True,
},
)
except Exception as e:
@@ -4899,9 +5028,15 @@ class ReticulumMeshChat:
return web.json_response(
{
"auth_enabled": self.auth_enabled,
- "password_set": self.config.auth_password_hash.get()
- is not None,
+ "password_set": (
+ self.config.auth_password_hash.get() is not None
+ if self.config
+ else False
+ ),
"authenticated": False,
+ "network_ready": bool(
+ self.current_context and self.current_context.running,
+ ),
"error": str(e),
},
)
@@ -15330,7 +15465,8 @@ class ReticulumMeshChat:
if not self.session_secret_key:
# try to migrate from current identity config if available
- self.session_secret_key = self.config.auth_session_secret.get()
+ if self.config is not None:
+ self.session_secret_key = self.config.auth_session_secret.get()
if not self.session_secret_key:
self.session_secret_key = secrets.token_urlsafe(32)
@@ -15340,14 +15476,18 @@ class ReticulumMeshChat:
except Exception as e:
print(f"Failed to write session secret to {session_secret_path}: {e}")
- # ensure it's also in the current config for consistency
- self.config.auth_session_secret.set(self.session_secret_key)
+ # ensure it's also in the current config for consistency when identity is ready
+ if self.config is not None:
+ self.config.auth_session_secret.set(self.session_secret_key)
# called when web app has started
async def on_startup(app):
# remember main event loop
AsyncUtils.set_main_loop(asyncio.get_event_loop())
+ if not self._network_ready:
+ self.start_network_setup_in_background()
+
# auto launch web browser
if launch_browser:
try:
@@ -20725,6 +20865,13 @@ def main():
)
sys.exit(1)
+ needs_immediate_network = bool(
+ args.self_check
+ or args.reset_password
+ or args.backup_db
+ or args.restore_db
+ or args.restore_from_snapshot,
+ )
reticulum_meshchat = ReticulumMeshChat(
identity,
args.storage_dir,
@@ -20741,12 +20888,13 @@ def main():
migration_context=migration_context,
memory_diag_enabled=args.memory_diag,
plugins_enabled=not args.disable_plugins,
+ defer_network_setup=not needs_immediate_network,
)
# store recovery on app for wiring with identity context
reticulum_meshchat._crash_recovery = recovery
- # update recovery with known paths
+ # update recovery with known paths (database_path may be unset until identity setup)
recovery.update_paths(
storage_dir=reticulum_meshchat.storage_dir,
database_path=reticulum_meshchat.database_path,
@@ -20820,6 +20968,7 @@ def main():
f"Snapshot restoration complete. Integrity check: {result['integrity_check']}",
)
reticulum_meshchat.setup_identity(identity)
+ reticulum_meshchat._mark_network_ready()
else:
print(f"Error: Snapshot not found at {snapshot_path}")
diff --git a/scripts/ci/android-emulator-smoke.sh b/scripts/ci/android-emulator-smoke.sh
new file mode 100755
index 00000000..b472335c
--- /dev/null
+++ b/scripts/ci/android-emulator-smoke.sh
@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+# Install a MeshChatX APK on a running emulator/device, launch MainActivity,
+# and fail if the Chaquopy backend does not become healthy.
+#
+# Expects: adb on PATH, one device/emulator online, APK path as $1 or MESHCHATX_APK.
+set -euo pipefail
+
+PACKAGE="${MESHCHATX_ANDROID_PACKAGE:-com.meshchatx}"
+ACTIVITY="${MESHCHATX_ANDROID_ACTIVITY:-com.meshchatx/.MainActivity}"
+STATUS_PATH="${MESHCHATX_SMOKE_STATUS_PATH:-/api/v1/status}"
+TIMEOUT_SEC="${MESHCHATX_SMOKE_TIMEOUT_SEC:-180}"
+LOGCAT_TAG_FILTER="${MESHCHATX_SMOKE_LOGCAT_FILTER:-Python|meshchat|MeshChat|chaquo|AndroidRuntime}"
+
+APK="${1:-${MESHCHATX_APK:-}}"
+if [[ -z "${APK}" ]]; then
+ echo "usage: $0 <path-to.apk>" >&2
+ exit 2
+fi
+if [[ ! -f "${APK}" ]]; then
+ echo "APK not found: ${APK}" >&2
+ exit 2
+fi
+
+if ! command -v adb >/dev/null 2>&1; then
+ echo "adb not found on PATH" >&2
+ exit 2
+fi
+
+adb wait-for-device
+deadline=$((SECONDS + 120))
+until adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -qx '1'; do
+ if (( SECONDS >= deadline )); then
+ echo "emulator did not finish booting within 120s" >&2
+ exit 1
+ fi
+ sleep 2
+done
+
+echo "Installing ${APK}"
+adb install -r -t --no-incremental "${APK}"
+
+echo "Clearing logcat and launching ${ACTIVITY}"
+adb logcat -c || true
+adb shell am force-stop "${PACKAGE}" || true
+adb shell am start -W -n "${ACTIVITY}"
+
+workdir="$(mktemp -d "${TMPDIR:-/tmp}/meshchatx-android-smoke.XXXXXX")"
+trap 'rm -rf "${workdir}"' EXIT
+logcat_file="${workdir}/logcat.txt"
+adb logcat -v time >"${logcat_file}" &
+logcat_pid=$!
+cleanup_logcat() {
+ kill "${logcat_pid}" >/dev/null 2>&1 || true
+ wait "${logcat_pid}" >/dev/null 2>&1 || true
+}
+trap 'cleanup_logcat; rm -rf "${workdir}"' EXIT
+
+fail_with_logs() {
+ local reason="$1"
+ echo "::error::Android emulator smoke failed: ${reason}"
+ echo "---- logcat (filtered) ----"
+ grep -E "${LOGCAT_TAG_FILTER}|PyException|SystemExit|ModuleNotFoundError|StorageLock|backend failed|Error starting MeshChatX" \
+ "${logcat_file}" | tail -n 200 || true
+ echo "---- logcat (tail) ----"
+ tail -n 120 "${logcat_file}" || true
+ exit 1
+}
+
+probe_status_ok() {
+ # Probe from inside the emulator (server binds 127.0.0.1 on-device).
+ # Prefer toybox wget (API 30+ images); fall back to python if present.
+ local body=""
+ if adb shell "command -v wget >/dev/null 2>&1" >/dev/null 2>&1; then
+ body="$(adb shell "wget -qO- --no-check-certificate https://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
+ if [[ -z "${body}" ]]; then
+ body="$(adb shell "wget -qO- http://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
+ fi
+ elif adb shell "command -v curl >/dev/null 2>&1" >/dev/null 2>&1; then
+ body="$(adb shell "curl -ksS --max-time 3 https://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
+ if [[ -z "${body}" ]]; then
+ body="$(adb shell "curl -fsS --max-time 3 http://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)"
+ fi
+ else
+ # Escape path for embedding in a double-quoted adb shell command.
+ local py_path
+ py_path="$(printf '%s' "${STATUS_PATH}" | sed 's/\\/\\\\/g; s/"/\\"/g')"
+ body="$(
+ adb shell "python3 -c \"
+import ssl, urllib.request
+ctx = ssl._create_unverified_context()
+urls = (
+ ('https://127.0.0.1:8000${py_path}', ctx),
+ ('http://127.0.0.1:8000${py_path}', None),
+)
+for url, context in urls:
+ try:
+ kwargs = {'timeout': 3}
+ if context is not None:
+ kwargs['context'] = context
+ print(urllib.request.urlopen(url, **kwargs).read().decode())
+ break
+ except Exception:
+ pass
+\"" 2>/dev/null | tr -d '\r' || true
+ )"
+ fi
+ printf '%s' "${body}" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"'
+}
+
+echo "Waiting up to ${TIMEOUT_SEC}s for backend health (${STATUS_PATH})"
+deadline=$((SECONDS + TIMEOUT_SEC))
+healthy=0
+while (( SECONDS < deadline )); do
+ if grep -Eiq 'PyException|ModuleNotFoundError: No module named|SystemExit: 1|MeshChatX backend failed|Error starting MeshChatX server' \
+ "${logcat_file}"; then
+ fail_with_logs "fatal backend error seen in logcat"
+ fi
+
+ if probe_status_ok; then
+ healthy=1
+ break
+ fi
+
+ sleep 3
+done
+
+if [[ "${healthy}" -ne 1 ]]; then
+ fail_with_logs "backend /api/v1/status did not become ok within ${TIMEOUT_SEC}s"
+fi
+
+echo "Android emulator smoke OK: ${STATUS_PATH} returned status=ok"
+grep -E "${LOGCAT_TAG_FILTER}" "${logcat_file}" | tail -n 40 || true
diff --git a/tests/test_android_emulator_smoke_script.py b/tests/test_android_emulator_smoke_script.py
new file mode 100644
index 00000000..db130600
--- /dev/null
+++ b/tests/test_android_emulator_smoke_script.py
@@ -0,0 +1,42 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Sanity checks for the Android emulator smoke CI script."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+from pathlib import Path
+
+_REPO = Path(__file__).resolve().parents[1]
+_SCRIPT = _REPO / "scripts" / "ci" / "android-emulator-smoke.sh"
+
+
+def test_smoke_script_exists_and_is_executable():
+ assert _SCRIPT.is_file()
+ assert os.access(_SCRIPT, os.X_OK)
+
+
+def test_smoke_script_bash_syntax():
+ subprocess.run(["bash", "-n", str(_SCRIPT)], check=True)
+
+
+def test_smoke_script_requires_apk_arg():
+ proc = subprocess.run(
+ ["bash", str(_SCRIPT)],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert proc.returncode == 2
+ assert "usage:" in (proc.stderr + proc.stdout)
+
+
+def test_workflow_references_smoke_script():
+ workflow = (
+ _REPO / ".github" / "workflows" / "android-emulator-smoke.yml"
+ ).read_text(encoding="utf-8")
+ assert "scripts/ci/android-emulator-smoke.sh" in workflow
+ assert "reactivecircus/android-emulator-runner@" in workflow
+ assert "MESHCHATX_ABIS" in workflow
+ assert "x86_64" in workflow
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────